CSS<color>型の文字列をパースする.js
2024-08-05
モチベ
つくってた譜面のテストプレイをしようとしたら描画バグが発生
調査の結果、Safari 17.5がrgb()のモダン構文を解釈できないせいでSyntaxErrorが発生してることが判明した
Safari 18では対応するっぽいが、待ってられるわけ無いだろ
ほなパーサー書くか……
xyz-d65は解釈できるっぽいので、すべての構文をxyz-d65の記法に変換するメソッドを書きます
別にどの色空間でもいいんですけど、xyz-d65なら直接変換がやりやすいので。
コード
注意
rgb(), hsl(), oklab(), oklch(), <named-color>, <hex-color>のみ対応しています
CSS Relative Color Syntaxには対応しています
使用例
code:example.js
ColorParser("red") // Expected Eval Value : <String> "color(xyz-d65 0.412391 0.212639 0.019331 / 1)"
ColorParser("#891546") // Expected Eval Value : <String> "color(xyz-d65 0.1168983081859918 0.06297795790110353 0.06394599134739502 / 1)"
ColorParser("rgb(from rgb(100 200 0 / .5) b g r)") // Expected Eval Value : <String> "color(xyz-d65 0.2295336042133023 0.4222676070276431 0.18997829385686904 / 1)"
本体
code:colorParser.js
/**
* CSS<color>型の文字列を解析して、RGB値を取得する
* - currentColorとtransparentはrgb(0 0 0 / 0)に変換
* - named-color, #rrggbbaa, rgb(r g b / a), hsl(h s l / a), oklab(L a b / a), oklch(L C h / a)とそれぞれの相対色構文に対応 (その他は非対応) * - %指定は非対応
* @param {String} colorStr - パースする色の文字列
* @returns {ColorSpace|String} - ColorSpaceクラスのインスタンスか、XYZ-d65色空間のcolor()文字列
*/
globalThis.ColorParser = function (colorStr, returnByColorSpace = false) {
/** @type {{ from: RegExp, to: String }} */
const namedColors = [
// 特殊キーワード
// CSS Color Module Level 3
{ from: /^black$/ig, to: 0, 0, 0 }, ];
/**
* @description - 各種類の判別関数
*/
const isNumber = str => str != "" && /^((1-90-9*)|0)?(\.0-9+)?$/.test(str); const isNamedColor = (tokenStr) => namedColors.some(nc => nc.from.test(tokenStr));
/**
* @description - colorStrのtokenize。ident(文字列), number(数値), hexCode(#+数値3/4/6/8桁), separator(スラッシュ・スペース・カンマ), groupStart(始め括弧), groupEnd(閉じ括弧)にわける
*/
/** @type {{ type: String, token: String }[]} */
const tokenList = [];
let latestToken = "";
const tokenLetter = {
};
/** @description - latestTokenを一つのトークンとしてtokenListにぶん投げる。identかnumberかhexCodeかは関数内で判別 */
const pushLatestTokenToTokenList = () => {
// 分岐処理はhexCode→number→identの順番で行う
if (isHexCode(latestToken)) {
tokenList.push({ type: "hexCode", token: latestToken });
} else if (isNumber(latestToken)) {
tokenList.push({ type: "number", token: latestToken });
} else if (isIdent(latestToken)) {
tokenList.push({ type: "ident", token: latestToken });
}
latestToken = "";
return;
};
for (let i = 0; i < colorStr.length; i++) {
const letter = colorStri; /* ==== もし今から見る文字が #・/・ ・,・(・) のいずれかなら、latestTokenに溜めてる分をtokenとしてリストに投げる ==== */ if (Array.from("#/ ,()").includes(letter)) pushLatestTokenToTokenList();
/* ==== もし今から見る文字が /・ ・,・(・) のいずれかなら、latestTokenに追加せずにtokenとしてリストに投げる ==== */
if (Array.from("/ ,").includes(letter)) {
tokenList.push({ type: "separator", token: letter });
continue;
}
if (letter === "(") {
tokenList.push({ type: "groupStart", token: letter });
continue;
}
if (letter === ")") {
tokenList.push({ type: "groupEnd", token: letter });
continue;
}
/* ==== それ以外なら、latestTokenに追加する ==== */
latestToken += letter;
}
pushLatestTokenToTokenList();
/* ==== separatorは消す ==== */
for (let i = 0; i < tokenList.length; i++) {
if (tokenListi.type === "separator") { tokenList.splice(i, 1);
i--;
}
}
/**
* identをnamedColor, functionName, それ以外(keyword)に分ける
*/
for (let i = 0; i < tokenList.length; i++) {
if (tokenListi.type === "ident") { if (isNamedColor(tokenListi.token)) { tokenListi.type = "namedColor"; } else if (isFunctionName(tokenListi.token)) { tokenListi.type = "functionName"; } else {
tokenListi.type = "keyword"; }
}
}
/**
* 色情報が得られるまでtokenを1つずつ読んで解釈する関数
*/
const readToken = () => {
/** @type {String} - 引数の解釈に使う色空間 */
let colorSpaceType = null;
/** @type {Number[]} - 引数(キーワードは数字に変換) */
let colorValues = [];
/** @type {ColorSpace} - 基準色(相対色構文の場合) */
let baseColor = null;
/** 無限ループで一つずつ読んでいく */
while (true) {
/** @type {Object} - 今から読むトークン */
let currentToken = tokenList.shift();
/** もうトークンがなければエラー */
if (!currentToken) throw new Error([ColorParser] : Unexpected end of input.);
/** トークンの種類によって処理を分岐 */
switch (currentToken.type) {
/* == namedColor : namedColorsから探してくる → RGBAの値を取得 → ColorSpaceインスタンスを作ってreturn == */
case "namedColor":
const rgb = namedColors.find(namedColor => currentToken.token.match(namedColor.from)).to;
return new ColorSpace("RGB", ...rgb);
/* == functionName : colorSpaceTypeにfunction名(≒色空間名)を書き込んで次へ == */
case "functionName":
colorSpaceType = currentToken.token;
break;
/* == hexCode : ColorSpaceインスタンスを作ってreturn == */
case "hexCode":
return new ColorSpace("Hex", currentToken.token);
/* == number : 数値をcolorValuesに追加 == */
case "number":
colorValues.push(parseFloat(currentToken.token));
break;
/* == groupStart : なにもしない == */
case "groupStart":
break;
/* == groupEnd : colorSpaceType, colorValuesをもとにColorSpaceインスタンスを作ってreturn == */
case "groupEnd":
return new ColorSpace({
"rgb": "RGB",
"hsl": "HSL",
"oklab": "OkLab",
"oklch": "OkLCh",
/* == keyword : from → 再帰呼び出しで色を取得してbaseColorに代入 / r, g, b, h, s, l, c, h, a, alpha → baseColorの該当値を読んでcolorValuesに追加 == */
case "keyword":
/* fromの場合 */
if (currentToken.token === "from") {
baseColor = readToken();
continue;
};
/* colorSpaceTypeがnullならエラー */
if (!colorSpaceType) throw new Error([ColorParser] : Unexpected keyword "${currentToken.token}".);
/* baseColorをcolorSpaceTypeに変換したときの各成分の値を配列で取得 */
const baseColorValues = baseColor.convert({
"rgb": "RGB",
"hsl": "HSL",
"oklab": "OkLab",
"oklch": "OkLCh",
/* キーワードが"alpha"の場合は先に代入しちゃう */
if (currentToken.token === "alpha") {
colorValues.push(baseColorValues3) ?? 1; continue;
}
/* colorSpaceTypeごとに分岐 */
switch (colorSpaceType) {
/* RGBの場合 */
case "rgb":
switch (currentToken.token) {
case "r":
colorValues.push(baseColorValues0); break;
case "g":
colorValues.push(baseColorValues1); break;
case "b":
colorValues.push(baseColorValues2); break;
/* いずれにも該当しなければエラー */
default:
throw new Error([ColorParser] : Unexpected keyword "${currentToken.token}".);
}
break;
/* HSLの場合 */
case "hsl":
switch (currentToken.token) {
case "h":
colorValues.push(baseColorValues0); break;
case "s":
colorValues.push(baseColorValues1); break;
case "l":
colorValues.push(baseColorValues2); break;
/* いずれにも該当しなければエラー */
default:
throw new Error([ColorParser] : Unexpected keyword "${currentToken.token}".);
}
break;
/* OkLabの場合 */
case "oklab":
switch (currentToken.token) {
case "L":
colorValues.push(baseColorValues0); break;
case "a":
colorValues.push(baseColorValues1); break;
case "b":
colorValues.push(baseColorValues2); break;
/* いずれにも該当しなければエラー */
default:
throw new Error([ColorParser] : Unexpected keyword "${currentToken.token}".);
}
break;
/* OkLChの場合 */
case "oklch":
switch (currentToken.token) {
case "L":
colorValues.push(baseColorValues0); break;
case "C":
colorValues.push(baseColorValues1); break;
case "h":
colorValues.push(baseColorValues2); break;
/* いずれにも該当しなければエラー */
default:
throw new Error([ColorParser] : Unexpected keyword "${currentToken.token}".);
}
break;
/* いずれにも該当しなければエラー */
default:
throw new Error([ColorParser] : Unexpected color space "${colorSpaceType}".);
}
}
}
};
return returnByColorSpace ? readToken() : readToken().convertAsCSS("XYZ", true);
};